Create tombstone module#736
Conversation
|
tombstone module name would be ideal i guess |
|
I can change the name with no problem if everyone agrees |
|
Probably a less terrifying module name😅 |
|
I like the name :P |
|
Name changed |
|
Hi, yes this is a known issue by me, but the problem is at impacket itself, I pushed a pr to impacket that fix something that was made when calling get_machine_name: but netexec uses another impacket, https://github.com/Pennyw0rth/impacket, when the pr that I made from impacket gets merged to the impacket that netexec use, then I can make the changes and the script will work again even when ntlm is not suported. (I think I will try to make the changes here already but it will not work while impacket does not get merged) |
|
New modules need to be added to the e2e tests |
|
Just to be sure I need to edit e2e_commands.txt right? But I think my fork has a different e2e because it is saying "This branch has conflicts that must be resolved" |
Then resolve them? |
|
First time doing that, learned something new |
Gonna update our impacket fork, give me a bit |
|
Done, our impacket should be up-to-date now |
|
Tested here again, working against domains where ntlm is disabled |
Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
…ript Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
When netexec impacket gets merged with the most up to date version from fortra this fix will work when a domain disabled ntlm Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
Removed the LDAP3 connection and now use impacket with the new CRUD method from the latest merged PR, removed the SSL option since now the first connection is handled by netexec and not ldap3 Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
|
I decided to remove the ldap3 dependency and use the LDAP CRUD implementation built on impacket. Decided to rewrite some parts of the code too and removed the SSL options since now netexec handles a valid connection. From my domain the code is working, but if anyone finds a bug please let me know |
fixed ruff checks Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
NeffIsBack
left a comment
There was a problem hiding this comment.
Thanks for removing ldap3.
A few things left to do, but overall the code looks already quite good :)
removed imports that were not being used, remove None returns, put the options at the start of the script and improved on indentation Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
|
There is a typo in the usage example for |
Removed a typo " in the first example Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
|
Removed the typo |
|
Hi! Are there news about this? Im currently using this module and its pretty valuable. |
|
Hi :) Unfortunately my time is really really short atm so I hadn't have the time yet to rereview it, but this should get better in Q3. My priority at the moment is to get #1191 merged since it eliminates a lot of issues with RPC and we should then have much more stable RPC interactions. However, since this PR waited quite a while now this is definitely high up on my priority list and I will review it soon afterwards. Feel free to ping me from time to time if I don't start working on it. |
| if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": | ||
| continue | ||
|
|
||
| context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}") |
There was a problem hiding this comment.
Not all tombstoned objects have sAMAccountName (OUs, contacts, etc.), so this raises KeyError and crashes the module, see the stack trace on query. Use .get() with a default (e.g. entries.get('sAMAccountName', '')) like that (ldap.py) :
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
for user in resp_parsed:
pwd_last_set = user.get("pwdLastSet", "")
if pwd_last_set:
pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
# We default attributes to blank strings if they don't exist in the dict
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}")
users.append(user.get("sAMAccountName", ""))
There was a problem hiding this comment.
code now uses context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}"), created an OU, removed and tested here, from the objects that I found, dn, ID, isDeleted and lastknowparent should be an attribute that is present on all types, but I could be wrong, in any case, this failsafe using .get() is being used at all attributes
| self.action = "query" | ||
| self.id = "" | ||
| self.deleteDN = "" | ||
| if "ACTION" in module_options: |
There was a problem hiding this comment.
ACTION is not validated, a typo (e.g., ACTION=restor) will not match any branch in on_login, and the module will exit silently. Please validate it in options() using .lower() and a whitelist (query, restore, delete), and use elif/else with log.fail in on_login for unknown values. You can see link_enable_cmdshell.py the pattern to follow
There was a problem hiding this comment.
Refactored the code to use .lower() and show an error message when an attribute that is not query, restore or delete gets passed
| context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}") | ||
| context.log.highlight("") | ||
|
|
||
| self.__sAMAccountName = entries["sAMAccountName"] |
There was a problem hiding this comment.
Same .get() issue here, see above
There was a problem hiding this comment.
Applied the same fix from the other issue, the code now uses the .get() when grabbing the attributes
| context.log.highlight("No objects are in a tombstone state") | ||
| return False | ||
|
|
||
| context.log.highlight(f"Found {len(resp) - 1} deleted objects") |
There was a problem hiding this comment.
Hmm, I think len(resp) - 1 doesn't match the number of objects actually displayed (the default container for deleted objects is filtered out in the loop). We should count the entries after filtering, or increment the value within the loop
There was a problem hiding this comment.
you are right, created a temporary var and increment for each value of deleted object that it founds, the number of objects is now shown at the end of the module
refactored the code to return empty sAMAccountName when object does not have this attribute. removed some variables that were not being used, such as opsec, multiple_host and self.__domain. removed internal domain to dn function to now use connection.baseDN. the number of deleted objects should be right now, temporary var is created and incremented for each deleted object found. Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
|
After @azoxlpf comment about the sAMAccountName not being present on all objects, I decided to change the logic at restore_deleted_objects, from my tests it is working, in the .modify() the samAccountName was being used to create the original DN of the user before deletion, now the originalDN is getting built from the DN and lastKnownParent. |



Description
This new module allows users to list, restore objects from "Deleted Objects" container, and delete active objects.
Listing deleted objects:
The module queries deleted objects using the LDAP filter
(isDeleted=TRUE)along with the controlLDAP_SERVER_SHOW_DELETED_OID, which is required to retrieve tombstoned entries. The query targets the DNCN=Deleted Objects,DC=.... By default, the first entry is skipped as it represents the container itself, not a real AD object (e.g., user or computer).Restoring objects:
To restore a deleted object, the module uses the ID obtained during the query and:
isDeletedattribute.distinguishedNamewith the value oflastKnownParent.This only works if the AD Recycle Bin is enabled and the user has the necessary permissions.
Deleting objects:
The module also supports deleting objects to return to their "tombstoned form" using the
.delete()method fromldap3. This is useful in scenarios where multiple objects with similar names exist (e.g., multiple versions of a user), and selective restoration or cleanup is needed.Type of change
How Has This Been Tested?
Tested on a local lab that I created and HTB, tried to test at my work but I don't have the privs to do that ;)
Screenshots (if appropriate):
After removing the user from this OU the query option (default) can be used to recover all the necessary informations from the Deleted Objects container.
Right now the user has no way to confirm if he has or not privileges (this is something that in a future update a DACL parser can be added but I don't know how to do that now).
To restore an object from the Deleted Objects the user needs to call the "restore" action and pass the ID value recovered from the query action.
The first call to ldap fail since the user is not active, after the restore action the ldap connection worked again.
To delete the object the DN must be passed to the deleted action.
Checklist:
poetry run python -m ruff check . --preview, use--fixto automatically fix what it can)References
To perform this attack some actions must be performed at the domain:
https://nettools.net/how-to-delegate-object-restoration-rights/
https://learn.microsoft.com/pt-br/troubleshoot/windows-server/active-directory/non-administrators-view-deleted-object-container
By default only DA can restore and list objects from the Deleted Objects, I created this module after doing some machines from htb and vulnlab that AD Recycle Bin was active and you need to do a winrm / rdp session to execute the Restore-ADObject cmdlet, with this module you don't need to have a valid windows session anymore.